"use client"; import Breadcrumb from "@/app/components/reuseableUI/breadcrumb"; import CommonButton from "@/app/components/reuseableUI/commonButton"; import PrimaryButton from "@/app/components/reuseableUI/primaryButton"; import Image from "next/image"; import { useParams, useRouter, useSearchParams, usePathname, } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { gtmViewItem, Product, } from "../../utils/googleTagManager"; import { useAppConfiguration } from "../../components/providers/ServerAppConfigurationProvider"; import { generateProductSchema, generateBreadcrumbSchema } from "@/lib/schema"; import ItemInquiryModal from "./components/itemInquiryModal"; import { ProductInquiryIcon } from "@/app/utils/svgs/productInquiryIcon"; import { partsLogicClient } from "@/lib/client/partslogic"; import type { FitmentData } from "@/lib/api/shopTypes"; import type { ProductDetailsByIdData } from "@/graphql/queries/productDetailsById"; /* No cart/checkout in this template. Product pages support "Request a Quote". */ type EditorBlock = | { id: string; type: "paragraph"; data: { text: string } } | { id: string; type: "header"; data: { text: string; level?: number } } | { id: string; type: "list"; data: { items: string[]; style?: "ordered" | "unordered" }; } | { id: string; type: "quote"; data: { text: string; caption?: string; alignment?: "left" | "center" | "right"; }; }; type InitialProduct = NonNullable; export function ProductDetailClient({ initialProduct }: { initialProduct: InitialProduct }) { const params = useParams<{ id: string }>(); // The URL param contains the normalized slug (with single dashes) // We need to pass the original Saleor slug for the API query // Since we can't perfectly reconstruct it, we just use the normalized version // and rely on Saleor's flexible slug matching const slug = params?.id ? decodeURIComponent(params.id as string) : ""; const router = useRouter(); const searchParams = useSearchParams(); const pathname = usePathname(); const [fitmentData, setFitmentData] = useState(null); const [fitmentLoading, setFitmentLoading] = useState(false); const [fitmentError, setFitmentError] = useState(null); const [showAllFitments, setShowAllFitments] = useState(false); // No auth/cart in this template; keep inquiry modal state only. const { getGoogleTagManagerConfig } = useAppConfiguration(); const gtmConfig = getGoogleTagManagerConfig(); // SEO/perf: product data is fetched on the server and passed in. // This avoids a client-side Apollo fetch (which would otherwise render a skeleton in HTML). const product = initialProduct; const loading = false; const error = null; const images = product?.media ?? []; const firstImageUrl = images[0]?.url ?? ""; // Track product view when product data is loaded useEffect(() => { if (product && !loading) { const productData: Product = { item_id: product.id, item_name: product.name, item_category: product.category?.name || "Products", price: product.pricing?.priceRange?.start?.gross?.amount || 0, currency: product.pricing?.priceRange?.start?.gross?.currency || "USD", item_brand: product.category?.name || undefined, }; gtmViewItem( [productData], productData.currency, productData.price, gtmConfig?.container_id ); } }, [product, loading]); const [selectedImage, setSelectedImage] = useState(firstImageUrl); const [selectedVariantId, setSelectedVariantId] = useState( null ); const [showInquiryModal, setShowInquiryModal] = useState(false); const [isInitialized, setIsInitialized] = useState(false); // Function to update URL with SKU parameter const updateURLWithSKU = useCallback( (sku: string | null) => { if (!sku) { // Remove SKU param if no SKU router.replace(pathname, { scroll: false }); return; } // Convert SKU to URL-friendly format (replace spaces with hyphens) const urlFriendlySKU = sku.replace(/\s+/g, "-"); const params = new URLSearchParams(); params.set("sku", urlFriendlySKU); const newURL = `${pathname}?${params.toString()}`; router.replace(newURL, { scroll: false }); }, [pathname, router] ); const raw = product?.description || ""; const lineHeight = 28; // px const maxLines = 10; const maxHeight = lineHeight * maxLines; const [showFull, setShowFull] = useState(false); const [isOverflow, setIsOverflow] = useState(false); const descriptionRef = useRef(null); useEffect(() => { if (descriptionRef.current) { const height = descriptionRef.current.scrollHeight; if (height > maxHeight) { setIsOverflow(true); } } }, [raw]); const toggleShow = () => setShowFull(!showFull); // Keep selected image in sync with first loaded image (simpler, no useMemo) useEffect(() => { if (firstImageUrl) setSelectedImage(firstImageUrl); }, [firstImageUrl]); // Initialize variant selection from URL or default to first variant useEffect(() => { if (!product?.variants?.length || isInitialized) return; const skuFromURL = searchParams.get("sku"); if (skuFromURL) { // Convert URL-friendly SKU back to original format (replace hyphens with spaces) const originalSKU = skuFromURL.replace(/-/g, " "); // Try to find variant by SKU from URL (check both formats for compatibility) const variantFromURL = product.variants.find( (v) => v.sku === originalSKU || v.sku === skuFromURL ); if (variantFromURL) { setSelectedVariantId(variantFromURL.id); setIsInitialized(true); return; } } // Default to first variant if no SKU in URL or SKU not found if (product.variants[0]?.id) { setSelectedVariantId(product.variants[0].id); setIsInitialized(true); } }, [product?.variants, searchParams, isInitialized]); const selectedVariant = useMemo(() => { if (!product?.variants?.length) return null; return ( product.variants.find((v) => v.id === (selectedVariantId ?? "")) ?? product.variants[0] ); }, [product, selectedVariantId]); // Update URL with SKU when variant changes (after initialization) useEffect(() => { if (isInitialized && selectedVariant?.sku) { updateURLWithSKU(selectedVariant.sku); } }, [isInitialized, selectedVariant?.sku, updateURLWithSKU]); // ---------- PRICING (variant-first) ---------- const variantPrice = selectedVariant?.pricing?.price?.gross ?? null; const variantOriginal = selectedVariant?.pricing?.priceUndiscounted?.gross ?? null; const rawCurrentPrice = variantPrice?.amount ?? product?.pricing?.priceRange?.start?.gross?.amount ?? 0; const currency = variantPrice?.currency ?? variantOriginal?.currency ?? product?.pricing?.priceRange?.start?.gross?.currency ?? "USD"; // ✅ Calculate original price correctly: discounted price + discount amount const discountAmount = product?.pricing?.discount?.gross?.amount ?? 0; const rawOriginalPrice = discountAmount > 0 ? rawCurrentPrice + discountAmount // Original = Current + Discount : variantOriginal?.amount ?? product?.pricing?.priceRange?.stop?.gross?.amount ?? null; // Format prices properly (convert from cents if needed) const currentPrice = rawCurrentPrice; const originalPrice = rawOriginalPrice; // ✅ Use Saleor's discount info for more accurate detection const hasDiscount = discountAmount > 0 || (typeof originalPrice === "number" && originalPrice > currentPrice); const compareAt = hasDiscount ? originalPrice : null; // Memoized formatter const moneyFmt = useMemo( () => new Intl.NumberFormat(undefined, { style: "currency", currency }), [currency] ); // -------------------------------------------- // Effect to update schema.org structured data when variant changes useEffect(() => { if (!product || !selectedVariant) return; const productSchema = generateProductSchema({ id: product.id, slug: product.slug, name: product.name, description: product.description || "", image: images.map((img) => img.url), price: currentPrice, currency: currency, availability: selectedVariant.quantityAvailable && selectedVariant.quantityAvailable > 0 ? "InStock" : "OutOfStock", sku: selectedVariant.sku || product.id, brand: product.category?.name, rating: undefined, reviewCount: undefined, }); const breadcrumbSchema = generateBreadcrumbSchema([ { name: "Home", url: "/" }, { name: "Products", url: "/products/all" }, { name: product.name, url: `/product/${slug}` }, ]); // Remove existing schema scripts const existingSchemas = document.querySelectorAll( "script[data-schema-type]" ); existingSchemas.forEach((script) => script.remove()); // Add updated product schema const productScript = document.createElement("script"); productScript.type = "application/ld+json"; productScript.setAttribute("data-schema-type", "product"); productScript.textContent = JSON.stringify(productSchema); document.head.appendChild(productScript); // Add breadcrumb schema const breadcrumbScript = document.createElement("script"); breadcrumbScript.type = "application/ld+json"; breadcrumbScript.setAttribute("data-schema-type", "breadcrumb"); breadcrumbScript.textContent = JSON.stringify(breadcrumbSchema); document.head.appendChild(breadcrumbScript); // Cleanup on unmount return () => { const schemas = document.querySelectorAll("script[data-schema-type]"); schemas.forEach((script) => script.remove()); }; }, [product, selectedVariant, currentPrice, currency, images, slug]); // Helper to read attribute value by slug from selected variant const getAttrVal = useCallback( (slug: string) => { const attr = selectedVariant?.attributes?.find( (a) => a.attribute?.slug === slug ); return attr?.values?.[0]?.name ?? null; }, [selectedVariant] ); const lengthVal = getAttrVal("length_in") || getAttrVal("length"); const heightVal = getAttrVal("height_in") || getAttrVal("height"); const widthVal = getAttrVal("width_in") || getAttrVal("width"); const requestQuote = () => setShowInquiryModal(true); useEffect(() => { const fetchFitmentData = async () => { try { setFitmentLoading(true); setFitmentError(null); const numericId = product?.id; if (!numericId) { setFitmentData(null); return; } const response = await partsLogicClient.getFitmentGroups(String(numericId)); setFitmentData((response as { data?: FitmentData[] }).data ?? []); } catch (error) { console.error("Error fetching fitment data:", error); setFitmentError("Failed to load fitment data"); } finally { setFitmentLoading(false); } }; fetchFitmentData(); }, [product?.id]); const processedFitmentData = useMemo(() => { if (!fitmentData || fitmentData.length === 0) return []; const fitmentSets: Array> = []; fitmentData.forEach((item) => { const groups = item.fitment_group_set.fitment_groups; const fitmentSet: Record = {}; groups.forEach((group) => { if (group.fitment_value.fitment.is_hidden) { return; } const key = group.fitment_value.fitment.fitment; const value = group.fitment_value.fitment_value; fitmentSet[key] = value; }); if (Object.keys(fitmentSet).length > 0) { fitmentSets.push(fitmentSet); } }); return fitmentSets; }, [fitmentData]); const displayedFitments = useMemo(() => { if (showAllFitments) { return processedFitmentData; } return processedFitmentData.slice(0, 50); }, [processedFitmentData, showAllFitments]); const hasMoreFitments = processedFitmentData.length > 50; const fitmentKeys = useMemo(() => { const keys = new Set(); processedFitmentData.forEach((fitmentSet) => { Object.keys(fitmentSet).forEach((key) => keys.add(key)); }); return Array.from(keys); }, [processedFitmentData]); const productBreadcrumbItems = [ { text: "HOME", link: "/" }, { text: "PRODUCT", link: "/products/all" }, { text: product?.name ?? "" }, ]; const baseText = "text-[var(--color-secondary-800)] font-secondary -tracking-[0.045px] leading-relaxed"; type HeadingTag = "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; // NOTE: Assumes product.description (Editor.js JSON) is already sanitized server-side. const renderBlock = (b: EditorBlock) => { switch (b.type) { case "quote": { const align = b.data.alignment === "center" ? "text-center" : b.data.alignment === "right" ? "text-right" : "text-left"; return (
{b.data.caption && (
)}
); } case "header": { // Prevent H1 from CMS content - page already has main H1 for product name const level = Math.min(Math.max(b.data.level ?? 2, 2), 6); const Tag = `h${level}` as HeadingTag; return ( ); } case "list": { const ordered = (b.data.style || "unordered") === "ordered"; const ListTag = (ordered ? "ol" : "ul") as "ol" | "ul"; return ( {b.data.items.map((it, i) => (
  • ))} ); } case "paragraph": default: { const html = (b.data.text || "").replace(/\n/g, "
    "); return (
    ); } } }; const renderDescription = () => { try { const parsed = JSON.parse(raw) as { blocks?: EditorBlock[] }; if (parsed?.blocks?.length) { return (
    {parsed.blocks.map(renderBlock)}
    {isOverflow && ( {showFull ? "View Less" : "View More"} )}
    ); } } catch { // Fallback to plain text return

    {raw}

    ; } }; const btnSecondary = "border border-gray-300 text-gray-700 hover:bg-gray-50 font-semibold transition-colors"; const isLoading = loading; const hasAnyDimension = parseFloat(lengthVal || "0") > 0 || parseFloat(widthVal || "0") > 0 || parseFloat(heightVal || "0") > 0 || (selectedVariant?.weight?.value ?? 0) > 0; const [isZoomed, setIsZoomed] = useState(false); const [mousePosition, setMousePosition] = useState({ x: 50, y: 50 }); const handleMouseMove = (e: React.MouseEvent) => { if (!isZoomed) return; const rect = e.currentTarget.getBoundingClientRect(); const x = ((e.clientX - rect.left) / rect.width) * 100; const y = ((e.clientY - rect.top) / rect.height) * 100; setMousePosition({ x, y }); }; const handleMouseEnter = () => setIsZoomed(true); const handleMouseLeave = () => { setIsZoomed(false); setMousePosition({ x: 50, y: 50 }); }; return ( <>
    {isLoading && (
    {Array.from({ length: 4 }).map((_, idx) => (
    ))}
    )} {error &&
    Failed to load product.
    } {!isLoading && !product && !error && (

    Product Not Found

    The product you're looking for doesn't exist or has been removed.

    router.push("/products/all")} variant="primary" className="mx-auto" > Browse All Products
    )} {product && (
    {/* Image Gallery */}
    {hasDiscount && ( Sale )} {selectedImage ? ( {product.name ) : ( {`${product.name} )}
    {images.length > 0 && (
    {images.map((img) => { const isActive = selectedImage === img.url; return ( ); })}
    )}
    {/* Product Info */}
    {/* Brand/Collection */} {/* {!!product.collections?.length && (
    BRAND {product.collections.map((c) => c.name).join(", ")}
    )} */}

    {product.name}

    {/* Meta: SKU and stock */} {selectedVariant && (
    SKU:{" "} {selectedVariant.sku}
    )} {/* Product Message from Metadata */} {(() => { const productMessage = product?.metadata?.find( (item) => item.key === "product_message" )?.value; const shippingIsActive = product?.metadata ?.find((item) => item.key === "shipping_isactive") ?.value?.toLowerCase() === "true"; // Only show the product message if shipping_isactive is true if (productMessage && shippingIsActive) { return (

    {productMessage}

    ); } return null; })()} {/* Variants */} {product?.metadata?.find((item) => item?.key === "availability") ?.value !== "Please Call" && ( <> {!!product.variants?.length && product.variants?.length !== 1 && (
    {product.variants.map((v) => { const selected = (selectedVariant?.id ?? product.variants[0].id) === v.id; return (
    setSelectedVariantId(v.id)} className={`border flex justify-between font-secondary w-full items-center px-4 py-5 cursor-pointer transition-colors ${ selected ? "border-[var(--color-primary-100)] bg-[var(--color-primary-50)] text-[var(--color-primary-700)]" : "border-[var(--color-secondary-200)] hover:bg-gray-50" }`} >
    setSelectedVariantId(v.id)} />

    {v.name}

    ); })}
    )} )} {/* Actions (no cart/checkout in this template) */} {product?.metadata?.find((item) => item?.key === "availability") ?.value !== "Please Call" && (
    router.push("/locator")} variant="secondary" > Where to Buy
    )} {/* Extra details (Dimensions/Weight) */} {hasAnyDimension && (

    Product Dimensions

      {lengthVal === "0" || parseFloat(lengthVal || "") == 0 ? null : (
    • Length:{" "} {lengthVal} Inches
    • )} {widthVal === "0" || parseFloat(widthVal || "") == 0 ? null : (
    • Width:{" "} {widthVal} Inches
    • )} {heightVal === "0" || parseFloat(heightVal || "") == 0 ? null : (
    • Height:{" "} {heightVal} Inches
    • )} {selectedVariant?.weight?.value === 0 ? null : (
    • Weight:{" "} {selectedVariant?.weight?.value} {selectedVariant?.weight?.unit}
    • )}
    )} {/* Description */}

    Product Description

    {renderDescription()}
    {fitmentLoading ? (

    DETAILS

    {Array.from({ length: 5 }).map((_, index) => (
    ))}
    ) : fitmentError ? (
    {fitmentError}
    ) : displayedFitments?.length > 0 ? (

    DETAILS

    {displayedFitments.map((fitmentSet, index) => { const entries = fitmentKeys .map((key) => ({ key, value: fitmentSet[key] })) .filter((entry) => entry.value); return (

    {entries.map((entry) => entry.key).join(" ")}: {" "} {entries.map((entry) => entry.value).join(" ")}

    ); })}
    {hasMoreFitments && ( setShowAllFitments(!showAllFitments)} className="text-sm md:text-base underline hover:underline-offset-4 px-0 hover:text-[var(--color-primary)]" > {showAllFitments ? "View Less" : "View More"} )}
    ) : null}
    )}
    setShowInquiryModal(false)} /> ); }